fix(items): approve openclaw device pairing in-container - #7
fix(items): approve openclaw device pairing in-container#7amiralis1365 wants to merge 3 commits into
Conversation
Deploying this item produced a Control UI nobody could get into. The
browser authenticated, then stopped at "device pairing required
(requestId: ...)" and named a CLI on the Gateway host to approve it.
Device pairing is a second gate, evaluated only after gateway token auth
has already succeeded. A new browser's request can be approved only by an
already-paired admin session, and on a fresh deployment nothing is ever
paired -- so the item deadlocked, and the remedy the error names needs a
host shell a one-click deployment does not have. Neither the token nor
the origin allowlist was at fault; both already pass before this point.
Satisfy the gate rather than remove it. The component now runs a watcher
beside the gateway that polls `devices list --json` and approves pending
requests. Upstream anticipates this: `shouldPreserveLocalCliSharedAuthScopes`
carries a dedicated `cli_container_local` locality that preserves operator
scopes for a token-authenticated CLI inside the gateway's own container,
so `devices approve` works over loopback without pairing of its own.
Rejected `gateway.controlUi.dangerouslyDisableDeviceAuth`, which clears
the same gate by discarding device identity altogether: browsers keep
their device keypair and revocable device token this way, `openclaw
security audit` stays clean, and the mechanism survives 2026.8.x, where
that key is retired and inert and would silently restore the deadlock on
an image bump. Also verified against the pinned release that
`allowInsecureAuth` ("does not bypass pairing checks", localhost-only)
and `nodes.pairing.autoApproveCidrs` (never applies to browser clients)
are not alternatives, despite both being widely cited as such.
Two greps rather than a JSON parser because the image ships no jq and a
node -e script cannot be quoted inside this scalar. `requestId` appears
only on pending entries -- paired rows carry deviceId, displayName,
roles, scopes, tokens and IP -- so the match cannot touch an existing
device.
The listing's claim that you just enter the token on first visit was
false for every deployment; it now describes both gates and says plainly
that the token is what guards an admin surface.
Verified: typecheck clean, full corpus 377/377; the folded scalar
resolves to one well-formed shell string whose --batch-json argument
parses as the three intended ops; `sh -n` accepts the inner script; and
the grep pipeline extracts exactly the pending requestId from a payload
carrying both a pending entry and a paired device, ignoring the latter's
UUID. Not verified -- no container runtime in the devcontainer: the live
smoke test that the watcher actually clears the pairing screen.
Closes #6
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpjD9Pkxh8LBhJPJQLzpx3
The watcher polled `devices list --json` every five seconds. That call is a cold Node start against a large CLI bundle, and the approve path is a second one, so a user's first connect sat in apparent failure for 10-15 seconds before the Control UI came up -- confirmed on a live deployment. Read the persisted pending-pairing file instead. The gateway writes each pending request to <stateDir>/devices/pending.json before it rejects the browser, and `resolveStateDir` resolves to OPENCLAW_STATE_DIR, else OPENCLAW_HOME/HOME joined with `.openclaw` -- which is exactly where this item mounts its config volume, so the path agrees by construction rather than by coincidence. Detection now costs two greps, spawns no Node process while nothing is pending, and leaves only one CLI start in the user's path. Poll drops to 1s because it is no longer expensive. Reading pending.json is also strictly better isolated than the previous `devices list` output: paired devices live in a separate file, so a paired entry cannot be matched at all. Verified: full corpus 377/377, typecheck clean; the folded scalar still resolves to the three intended config ops; `sh -n` accepts the inner script; and against fixtures written in the persisted shape the pipeline extracts exactly the pending requestId, yields nothing for paired.json, and handles the file being absent (it does not exist until a browser first pairs). The previous approach was confirmed working end-to-end on a live deployment; this change alters only how a pending request is detected, not what is approved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpjD9Pkxh8LBhJPJQLzpx3
|
Confirmed working on a live deployment: the pairing screen clears and the Control UI loads. That closes the one thing this PR listed as unverified. Live testing surfaced a latency problem, fixed in dad9442. The first connect sat in apparent failure for 10-15 seconds before coming up. Cause was the polling design, not the approval itself: The watcher now reads the persisted pending file instead. The gateway writes each pending request to Result: detection costs two greps, no Node process spawns while nothing is pending, and only one CLI start remains in the user's path. Poll interval drops to 1s now that a tick is cheap. Reading Re-verified: corpus 377/377, typecheck clean, Still worth a smoke test on the new path before merge, since only the previous polling approach has run live. |
…ring watcher
Replace the in-container `devices approve` watcher with
`gateway.controlUi.dangerouslyDisableDeviceAuth`. The intended posture for
this item is that anyone holding the gateway token gets in from any
browser, with no device enrolled or allowlisted, and no background process
load-bearing for reachability.
Gateway auth is untouched, despite the key's name. Reading the connect
path in the 2026.7.1 bundle, three gates run in order: device identity
(allowBypass admits), then an unconditional
`if (!authOk) { rejectUnauthorized(authResult); return; }`, then the
pairing gate (allowBypass skips). Gate two carries no isControlUi test and
no allowBypass exemption and sits between the two the key affects, so the
token check stays fully enforced while device identity and pairing go
away.
The watcher it replaces worked and was confirmed live, but it made
reachability depend on a polling loop and put a cold CLI start in front of
every first connect. Traded for a declarative key and an instant first
load. The cost is stated in both files: no second factor, no per-device
revocation, and browsers re-present the token each session rather than
holding a stored device token.
The image tag is now load-bearing and says so. 2026.7.1 is the last stable
release honouring this key: its `shouldSkipControlUiPairing` ends
`return role === "operator" && policy.allowBypass`, while 2026.8.1 and
2026.8.2 both end `return null`, and upstream calls the key "a retired
break-glass input, now fully inert" with `doctor --fix` deleting it. On
2026.8.x the controlUi schema no longer offers a device-auth toggle at
all, and the surviving bypasses are trusted-proxy `deviceAutoApprove`
(needs an identity-injecting edge) or node-role pairing policy (excludes
browsers). A tag bump therefore silently restores the deadlock; the
comment says what has to change alongside it.
Verified: full corpus 377/377, typecheck clean, `sh -n` accepts the inner
script, and the folded scalar resolves to four config ops with
dangerouslyDisableDeviceAuth carried as a JSON boolean rather than a
string.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpjD9Pkxh8LBhJPJQLzpx3
Closes #6.
Deploying the
openclawitem produced a Control UI nobody could get into: the browser authenticated, then stopped permanently atdevice pairing required (requestId: ...), pointing at a CLI on the Gateway host.What was wrong
Device pairing is a second gate, evaluated only after gateway token auth has already succeeded. A new browser's pairing request can be approved only by an already-paired admin session, and on a fresh deployment nothing is ever paired. The item deadlocked, and the remedy the error names needs a host shell that a one-click deployment does not provide.
Neither the token nor the origin allowlist was at fault — both already pass before this point. It is also not a WebSocket or missing-port problem: the
requestIdis minted server-side and returned in the handshake error payload, so rendering it proves the WS session completed.What this does
Satisfies the gate instead of removing it. The component now runs a watcher beside the gateway that polls
devices list --jsonand approves pending requests. Upstream anticipates exactly this —shouldPreserveLocalCliSharedAuthScopescarries a dedicatedcli_container_locallocality that preserves operator scopes for a token-authenticated CLI inside the gateway's own container, sodevices approveworks over loopback without pairing of its own.Both auth layers stay intact: the token is still required at the handshake, and every browser still binds a device keypair and receives its own revocable device token.
Why not
dangerouslyDisableDeviceAuthIt clears the same gate, but by discarding device identity altogether. Keeping the gate means browsers retain revocable device tokens,
openclaw security auditstays clean, and — decisively — the mechanism survives 2026.8.x, where that key is retired and inert and would silently restore this deadlock on any image bump.Also verified against the pinned release (
npm pack openclaw@2026.7.1, not the docs site, which documents newer builds) that the commonly-cited alternatives are not alternatives:controlUi.allowInsecureAuthnodes.pairing.autoApproveCidrstrustedProxy.deviceAutoApproveauth.mode: "none"Implementation note
Two greps rather than a JSON parser: the image ships no
jq, and anode -escript cannot be quoted inside this scalar.requestIdappears only on pending entries — paired rows carrydeviceId,displayName,roles,scopes, tokens and IP — so the match cannot touch an existing device.Listing
The description claimed you simply enter the token on first visit. That was false for every deployment. It now describes both gates and states plainly that the token is what guards an admin surface.
Verification
npm run typecheckclean; full corpus 377/377.--batch-jsonargument parses as the three intended ops.sh -naccepts the inner script (550 bytes), covering the nested quoting, pipeline, background subshell andexec.requestIdfrom a payload carrying both a pending entry and a paired device, ignoring the latter's UUID.Not verified — no container runtime in the devcontainer: the live smoke test that the watcher actually clears the pairing screen. Given #3 was reverted for resting on documentation alone, this should be exercised against a running image before merge.
🤖 Generated with Claude Code
https://claude.ai/code/session_01LpjD9Pkxh8LBhJPJQLzpx3